04 - DBOS 与 Restate
前置:03 篇的确定性约束。两个方案都在回应 Temporal 的接入成本问题。
本篇回答:不额外运维一套分布式系统,能不能拿到持久化执行?代价是什么?
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 系统库 | DBOS 在你自己的 Postgres 里建的那套表,用来存工作流状态和每步的输出。「只要一个 Postgres」指的就是不用再额外部署别的东西 |
| PENDING | 工作流的一种状态,表示「开始了但还没跑完」。DBOS 的恢复就是从扫描这个状态开始的 |
| Conductor | DBOS 的协调组件。多实例部署时靠它仲裁,保证一个中断的任务只被一个实例捡起来 |
| 写放大 | 一次业务操作在数据库里引发的实际写入次数。DBOS 每个 step 一次写、每条工作流再加两次,步数多时这个量不可忽略 |
run() | Restate 的语句级标注:把不确定的调用包进去,框架就会记住它第一次的返回值。漏包一处不会报错,只在崩溃恢复时才出问题 |
| Awakeable | Restate 提供的一个可以被外部唤醒的挂起点,带唯一 ID。人工审批、等待回调都用它 |
一、DBOS:用一个 Postgres 承载状态
DBOS 的路线是取消独立的工作流服务,状态直接写进 Postgres。官方描述系统数据库里存的是"所有工作流检查点、步骤输出, 以及调度和队列状态",且"每一个工作流输入和步骤输出都被持久化存储在系统数据库里"。
1.1 三阶段恢复
这正好补上 02 篇 5.1 节指出的 LangGraph 缺口 —— 谁来把中断的任务捡起来。
官方对二、三阶段的描述:
DBOS restarts each interrupted workflow by calling it with its checkpointed inputs. As the workflow re-executes, it checks before each step if that step's output is checkpointed in Postgres.
When reaching an uncheckpointed step, the recovered workflow executes that step normally and proceeds from there, thus resuming from the last completed step.
1.2 它与 Temporal 是同一个模型
恢复机制完全一致 —— 从头重跑,已完成的步骤直接返回记录值。 区别只在历史存在哪里:Temporal 存在自己的集群,DBOS 存在你的 Postgres。
因此 03 篇的全部确定性约束在这里同样成立。官方也明确要求工作流确定性、步骤幂等。
这一点容易被"只要一个 Postgres"的轻量宣传掩盖:接入成本降低了,认知成本没有降低。你依然不能在 工作流函数里直接调模型,依然要担心改代码破坏重放。
1.3 检测阶段的两种模式
| 模式 | 机制 | 适用 |
|---|---|---|
| 单节点 | 进程启动时自动扫描 PENDING | 单实例部署 |
| 分布式 | 通过 Conductor 协调 | 多实例部署 |
第二种是必需品而非增强项:
自行实现恢复逻辑时,这一点是最常见的遗漏 —— 恢复机制本身会变成故障放大器。
1.4 写放大:这个方案的主要代价
官方给出的开销:
one database write per step (to checkpoint the step's outcome) plus two additional database writes per workflow (one at the beginning to checkpoint workflow inputs, one at the end to checkpoint the workflow outcome)
量化到 Agent 场景:
| 项 | 数值 |
|---|---|
| 单个 30 步任务的数据库写入 | 32 次 |
| 平台每天 10 万个任务 | 320 万次写入 / 天 |
| 单步模型输出约 20 KB,30 步 | 600 KB / 任务 |
| 每天 10 万任务的存储增量 | 约 60 GB / 天 |
官方也提示了后半个问题:检查点大小取决于数据量,小的步骤输出开销可忽略,大的输出需要仔细的架构考量。
Agent 场景恰好落在"大输出 + 多步骤"的最坏组合上。 务实做法是把大的中间结果(长文本、抓取的网页、生成的报告)存到对象存储,检查点里只放引用:
@DBOS.step()
def fetch_and_summarize(url: str) -> dict:
"""步骤的返回值会被完整写进 Postgres。
因此这里返回的必须是「引用 + 摘要」,而不是整个网页正文 ——
否则每一步都会往数据库里塞几十 KB。"""
html = fetch(url) # 可能有几百 KB
key = object_store.put(html) # 大对象存到 S3 / OSS,拿一个 key
summary = call_model(f"总结:{html[:8000]}")
return {"blob_key": key, "summary": summary} # 只有这个进检查点
持久化执行的瓶颈从来不是 CPU,是存储写入量。 这与 02 篇 1.3 节里 LangGraph 做 DeltaChannel 要解决的是同一个问题。
二、Restate:把不确定性变成显式标注
Restate 用 Rust 实现,星数高于 DBOS(4,308 对 1,532)。
2.1 与 Temporal 的粒度差异
03 篇里 Temporal 要求把不确定操作放进 Activity —— 这是函数级的结构拆分。Restate 提供 run(),做语句级标注:
Without
run(), these operations would produce different results during replay, breaking deterministic recovery.
# 不确定操作必须包在 run() 里,其返回值会被记入执行日志。
# 重放时不再真正调用,直接返回日志中的结果。
summary = await ctx.run("call-model", lambda: call_model(prompt))
# ⚠️ 漏包的后果:下面这行在首次执行时完全正常,
# 但重放时会真的再打一次模型,且返回值可能不同 → 后续路径分叉。
# 编译器和运行时都不会提示。
summary = call_model(prompt) # 错误写法
代价的性质不同:函数级拆分是编译期可见的结构,语句级标注漏了照样能跑,只在恢复时暴露。
2.2 Awakeable:挂起等待外部事件
Restate 把"等待外部事件"做成了一等公民:
| 原语 | 官方描述 |
|---|---|
| Signal | "A durable notification addressed by invocation ID and signal name" |
| Awakeable | "A convenient shorthand for a one-shot signal",带唯一 ID |
| Workflow promise | 按 workflow key 作用域的具名值 |
这直接对应 Agent 里的人在环场景:
与 02 篇里 LangGraph 的 Interrupt 解决同一问题,差异在于:LangGraph 的中断只在图内、需要外部主动再调用一次;Restate 的挂起由运行时管理,可挂任意久。
2.3 状态保留期是个需要确认的细节
官方说明状态"对 Virtual Object 无限期保留,对 Workflow 按配置的保留期"。
Workflow 的状态会过期。 若人在环审批可能拖两周,必须确认保留期配置足够 —— 否则审批人回来点批准时,工作流已被清理。
三、三条轻量路线对照
| LangGraph | DBOS | Restate | |
|---|---|---|---|
| 额外基础设施 | 无 | 无(用你的 Postgres) | 一个运行时进程 |
| 自动恢复中断任务 | ❌ 需自写巡检 | ✅ 三阶段恢复 | ✅ |
| 确定性约束 | 弱(快照式) | 强(重放式) | 强(重放式) |
| 对已有代码的侵入 | 仅限图内 | 装饰器 + 结构调整 | run() 语句级标注 |
| 挂起等外部事件 | Interrupt,需外部再调用 | 队列 | Awakeable,一等公民 |
| 写放大 | 每超级步一次 | 每步一次 + 每流两次 | 每 run() 一次 |
| 多语言 SDK | Python / JS | Python / TS / Go / Java | 多语言 |
3.1 最容易被忽略的一行
第三行。LangGraph 是快照式,不重放代码,因此没有确定性约束 —— 节点内可以随意调模型、读时间、用随机数。
DBOS 和 Restate 都是重放式,完整继承了 Temporal 的确定性心智负担。
这个分界比"要不要跑集群"更重要,但在三家的宣传材料里几乎不被强调。"只要一个 Postgres"降低的是运维成本,不是认知成本。
下一篇 → 05 - 选型与落地
← 回到 专题索引 · Agent Infra 板块总览